Angular 的脏值检查机制一直是 Angular 被人诟病的地方,但瑕不掩瑜,Angular 还是一个非常优秀的框架,并且 Angular2 也已经抛弃了这个脏值检查的算法。
最近在看《AngularJS 深度剖析与最佳实践》,不得不说是一本很好的书籍,作者在第三章开始讲背后的原理,这里分析了 Angular 的 $digest 函数,即脏检查机制。所以自己也去下载了 Angular 最新的源码去瞧了下,然后做下笔记吧。

首先要注意,Angular 的 digest 的触发不是定时的,只有在指定的事件触发之后才会进入 $digest。基本上我们用的带 $ 的东西调用之后都可能会触发 digest。比如我们使用 setTimeout 就不会触发 digest,即当你使用 setTimeout 更改 viewmodel 的值后,它不会同步的反映到用户的视图中去,解决方法有两个,一个是使用 Angular 提供的 $timeout 替代 setTimeout$timeout 会在执行结束之后自动触发 digest; 另一个方法是手动调用 $apply,$apply 是 Angular 对 digest 的一层封装,我们一般不会直接调用 digest 而是通过使用 $apply 方法。比如对于 setTimeout,我们就可以这样触发 digest。

1
2
3
4
5
setTimeout(() => {
$scope.$apply(() => {
$scope.test = 123;
})
}, 500);

我们看一个例子,这也是 Angular 源码 $digest 部分的一个示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var scope = ...;
scope.name = 'misko';
scope.counter = 0;
expect(scope.counter).toEqual(0);
scope.$watch('name', function(newValue, oldValue){
scope.counter = scope.counter + 1;
});
expect(scope.counter).toEqual(0);
// 执行第一次 digest,第一次 digest 会遍历全部的 watcher,并触发上面的方法,从而使的 count+1
scope.$digest();
expect(scope.counter).toEqual(1);
// 第二次调用时,由于上一次调用检查 name 不脏,所以不会再去处理
scope.$digest();
expect(scope.counter).toEqual(1);
// 第三次调用时,由于 name 发生了变化,使得当前值和上一次保存的值不同,所以会触发起 $watch 方法
scope.name = 'adam';
scope.$digest();
expect(scope.counter).toEqual(2);

Angular 的脏值检查过程大致如下:
对当前作用域和子作用域上的 $$watchers 进行遍历,$$watches 保存着 scope 上的所有变量以及其 $watch 方法,调用时会取当前值和上一次值进行比较,如果不相等则会调用 $watch 方法,同时会保存当前的值以在下一次进行比较,并且记录此次检查结果为脏。然后重复进行直到数据不脏为止,因此至少要 digest 两次,超出 10 次会报错,可以调高这个次数限制。当数据不再脏即 model 稳定下来之后, Angular 才会开始一次性批量更新 UI。从而减少了浏览器的 repaint 次数,提升性能。

深入到源码来看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
$digest: function() {
var watch, value, last, fn, get,
watchers,
length,
dirty, ttl = TTL,
next, current, target = this,
watchLog = [],
logIdx, asyncTask;
beginPhase('$digest');
$browser.$$checkUrlChange();
if (this === $rootScope && applyAsyncId !== null) {
$browser.defer.cancel(applyAsyncId);
flushApplyAsync();
}
lastDirtyWatch = null;
do {
dirty = false;
current = target;
for (var asyncQueuePosition = 0; asyncQueuePosition < asyncQueue.length; asyncQueuePosition++) {
try {
asyncTask = asyncQueue[asyncQueuePosition];
asyncTask.scope.$eval(asyncTask.expression, asyncTask.locals);
} catch (e) {
$exceptionHandler(e);
}
lastDirtyWatch = null;
}
asyncQueue.length = 0;
// 脏值检查开始
traverseScopesLoop:
do {
// 获取当前 scope 的 $$watchers
if ((watchers = current.$$watchers)) {
// process our watches
// 遍历执行这些 watches
length = watchers.length;
while (length--) {
try {
watch = watchers[length];
if (watch) {
get = watch.get;
if ((value = get(current)) !== (last = watch.last) &&
!(watch.eq
? equals(value, last)
: (typeof value === 'number' && typeof last === 'number'
&& isNaN(value) && isNaN(last)))) {
// 优先使用 === 判断 value 和 last,其次再是根据他们是否为数字做 ng 的深度相等判断或者 isNaN 判断
dirty = true;
lastDirtyWatch = watch;
// 如果 watch.eq 为 true,表示该 watch 的目标为对象,所以把该对象克隆到 watch.last 上面以下一次 digest 时来判断
// 如果 watch.eq 为 false,表示该 watch 的目标为数字,所以直接赋值就可以了
// 这里和上面一样都是为了提高速度和性能用
watch.last = watch.eq ? copy(value, null) : value;
// 获取该 watch 的表达式并执行
fn = watch.fn;
// 如果 last 和最开始的值相同则使用后者,否则使用前者。
fn(value, ((last === initWatchVal) ? value : last), current);
if (ttl < 5) {
logIdx = 4 - ttl;
if (!watchLog[logIdx]) watchLog[logIdx] = [];
watchLog[logIdx].push({
msg: isFunction(watch.exp) ? 'fn: ' + (watch.exp.name || watch.exp.toString()) : watch.exp,
newVal: value,
oldVal: last
});
}
} else if (watch === lastDirtyWatch) {
dirty = false;
break traverseScopesLoop;
}
}
} catch (e) {
$exceptionHandler(e);
}
}
}
// Insanity Warning: scope depth-first traversal
// yes, this code is a bit crazy, but it works and we have tests to prove it!
// this piece should be kept in sync with the traversal in $broadcast
// 对当前 scope 的子 scope 做遍历
if (!(next = ((current.$$watchersCount && current.$$childHead) ||
(current !== target && current.$$nextSibling)))) {
while (current !== target && !(next = current.$$nextSibling)) {
current = current.$parent;
}
}
} while ((current = next));
// 脏值检查未结束但此时 ttl 为 0,则抛出错误
if ((dirty || asyncQueue.length) && !(ttl--)) {
clearPhase();
throw $rootScopeMinErr('infdig',
'{0} $digest() iterations reached. Aborting!\n' +
'Watchers fired in the last 5 iterations: {1}',
TTL, watchLog);
}
// 循环遍历直到 dirty 为 false 并且 asyncQueue.length = 0
} while (dirty || asyncQueue.length);
clearPhase();
// 执行 postDigest 序列
while (postDigestQueuePosition < postDigestQueue.length) {
try {
postDigestQueue[postDigestQueuePosition++]();
} catch (e) {
$exceptionHandler(e);
}
}
postDigestQueue.length = postDigestQueuePosition = 0;
}

不过这段代码我也不是全都理解了,但是核心的算了解了。总体来看这个算法还是很简单粗暴的,这里保留了一段注释,有意思,官方吐槽的感觉。

由于脏检查的性能问题,在页面绑定数据较多的时候,我们应该尽量减少双向绑定的数量,比如使用 ngInfiniteScroll 这样的插件,适当使用单向绑定,甚至是取消一些变量的 watch 方法。